Skip to content

fix: LightGBM Ensure non-duplicate column names - #2508

Open
Rana Singh (ranadeepsingh) wants to merge 9 commits into
microsoft:masterfrom
ranadeepsingh:rana/fix-lightgbm-error
Open

fix: LightGBM Ensure non-duplicate column names#2508
Rana Singh (ranadeepsingh) wants to merge 9 commits into
microsoft:masterfrom
ranadeepsingh:rana/fix-lightgbm-error

Conversation

@ranadeepsingh

@ranadeepsingh Rana Singh (ranadeepsingh) commented Feb 27, 2026

Copy link
Copy Markdown
Collaborator

Related Issues/PRs

Fixes #2242

Fixes the LightGBM training failure Feature (Column_) appears more than one time.

What changes are proposed in this pull request?

LightGBM rejects a Dataset whose feature names repeat. Spark can surface repeated names through the AttributeGroup metadata of the features column (and a user can pass repeated slotNames directly), so training failed in the native LGBM_DatasetSetFeatureNames call with:

Dataset set feature names call failed in LightGBM with error:
Feature (Column_) appears more than one time.

Changes

  • De-duplicate feature names before handing them to LightGBM. getSlotNamesWithMetadata now routes both metadata-derived names and explicit slotNames through ensureUniqueFeatureNames, which appends a numeric suffix to repeated names while preserving order (feature names are positional in LightGBM).
  • Name the streaming reference Dataset. createReferenceDatasetFromSample now accepts the feature names and applies them, so the reference Dataset carries the same names as the per-partition Datasets.
  • Split createReferenceDatasetFromSample into createDatasetFromSamples, setFeatureNamesIfProvided, and serializeAndCleanup.
  • Validate slot names unconditionally. validateSlotNames previously ran only when the features column carried AttributeGroup metadata, so explicit slotNames containing " , : [ ] { } were never checked. See the behavior-change note below.
  • Validate slot names before the first native call that consumes them. Naming the reference Dataset introduces an LGBM_DatasetSetFeatureNames call inside calculateRowStatistics, which runs earlier in trainOneDataBatch than validateSlotNames did. validateSlotNames is now called as soon as featuresSchema is resolved.

Correctness notes

Generated names cannot collide with later original names. A dedup pass that only remembers the names it has already emitted is not sufficient. For ["Column_", "Column_", "Column__1"] it renames the second Column_ to Column__1, which the third slot already owns, so LightGBM still fails — on the exact error this PR exists to fix. Every original name is therefore reserved up front, and generated names are reserved as they are handed out. This is covered by a dedicated test that was confirmed to fail against the naive implementation with Feature (Column__1) appears more than one time.

Uniqueness is decided on LightGBM's terms. LightGBM replaces every space in a feature name with an underscore before checking for duplicates, so "a b" and "a_b" are one feature natively while being two distinct strings in Scala. Comparing raw strings therefore still let the target error through:

Dataset set feature names call failed in LightGBM with error:
Feature (a_b) appears more than one time.

Collisions are now detected on the normalized form while the original names are still what get emitted. Confirmed empirically in both directions.

No bulk-mode retry. An earlier revision of this branch caught the failure and retried the batch with dataTransferMode=bulk. That fallback was removed because it could not work:

  1. BulkPartitionTask calls setFeatureNames with the same still-duplicated names, so the retry fails identically. This is the decisive point and holds regardless of which native call reports the duplicate.
  2. Its guard also required the message to contain dataset create. That matches the call site in #2242 (Dataset create from samples), but on current master the duplicate is reported by LGBM_DatasetSetFeatureNames, whose component string is Dataset set feature names, so the guard no longer matches the failure it was written for. Confirmed empirically; the failure text is quoted above.

It also mutated the estimator's dataTransferMode param mid-fit, which is visible to concurrent callers and to the model built during the retry. Deduplicating the names up front removes the need for a fallback entirely.

Native handle cleanup. createDatasetFromSamples frees its voidpp handle in a finally, matching deserializeReferenceDataset. The pre-existing code allocated two handles and freed only the unused one, leaking the one it actually used on every streaming fit().

The reference Dataset itself is now also freed in a finally spanning both naming and serialization. Previously it was freed only on the success path, so the naming call this PR adds would have leaked it whenever a name was rejected. That free is intentionally not routed through LightGBMUtils.validate, because throwing from the finally would mask the original failure.

Backslash is not rejected. The invalid-slot-name message listed \ as a disallowed character, but the regex never matched it and LightGBM does not reject it either — CheckAllowedJSON rejects exactly " , : [ ] { }. The message now matches both the regex and the native behavior. The regex is deliberately unchanged; adding \ would reject names LightGBM accepts.

Feature-name count guard. LGBM_DatasetSetFeatureNames reads numCols entries from the array, so a shorter array would be an out-of-bounds native read. When the counts disagree the names are skipped with a warning, which is the behavior prior to this change.

Validation must precede the native call. LightGBM rejects " , : [ ] { } in feature names itself, but only with Do not support special JSON characters in feature name. — it does not say which column is at fault. Because naming the reference Dataset moves the first native feature-name call earlier in fit, validating afterwards let that opaque error win the race and broke the existing Verify LightGBM Regressor with bad column names fails early test. Validating up front restores the actionable IllegalArgumentException, and the test now fails in 0.3s instead of after the sampling pass — which is what "fails early" is asserting.

Behavior changes

  • Repeated feature names are now renamed rather than fatal. A warning lists the names that were renamed.
  • validateSlotNames now also validates explicitly supplied slotNames. A job that passed slotNames containing " , : [ ] { } while the features column had no attribute metadata previously reached LightGBM unchecked and now fails fast with the existing, actionable IllegalArgumentException.

How is this patch tested?

  • I have written tests (not required for typo or doc fix) and confirmed the proposed feature/bug-fix/change works.

Added to VerifyLightGBMCommon:

Test Covers
Verify duplicate feature names are handled correctly Repeated names arriving through AttributeGroup metadata
Verify explicit slotNames parameter is used Explicit slotNames still applied
Verify duplicate explicit slotNames are made unique Repeated user-supplied slotNames
Verify a generated slot name cannot collide with a later original name The collision case above; verified to fail against the naive implementation
Verify names differing only by space vs underscore are made unique LightGBM's internal space-to-underscore normalization; verified to fail before the normalization fix

Validated locally on Java 11 against the full CI dataset set: VerifyLightGBMCommon 9/9, VerifyLightGBMRegressorStream and VerifyLightGBMRankerStream 26/26 (including Verify LightGBM Regressor with bad column names fails early), VerifyLightGBMRegressorBulk, VerifyLightGBMRankerBulk, VerifyLightGBMClassifierStreamOnly, NetworkManagerSuite and TrainUtilsSuite 52/52, and lightgbm/scalastyle plus lightgbm/Test/scalastyle report 0 errors.

An earlier revision of this branch also set setUseBarrierExecutionMode(true) in LightGBMRankerTestData and LightGBMRegressorTestData. Those are EstimatorFuzzing base classes, so the flag applied to every ranker and regressor test rather than to anything this PR changes; it has been reverted.

Rebased onto current master (68 commits, including #2595 and #2578) with no conflicts.

Does this PR change any dependencies?

  • No. You can skip this section.

Does this PR add a new feature? If so, have you added samples on website?

  • No. You can skip this section.

@ranadeepsingh

Copy link
Copy Markdown
Collaborator Author

/azp run

@github-actions

Copy link
Copy Markdown

Hey Rana Singh (@ranadeepsingh) 👋!
Thank you so much for contributing to our repository 🙌.
Someone from SynapseML Team will be reviewing this pull request soon.

We use semantic commit messages to streamline the release process.
Before your pull request can be merged, you should make sure your first commit and PR title start with a semantic prefix.
This helps us to create release messages and credit you for your hard work!

Examples of commit messages with semantic prefixes:

  • fix: Fix LightGBM crashes with empty partitions
  • feat: Make HTTP on Spark back-offs configurable
  • docs: Update Spark Serving usage
  • build: Add codecov support
  • perf: improve LightGBM memory usage
  • refactor: make python code generation rely on classes
  • style: Remove nulls from CNTKModel
  • test: Add test coverage for CNTKModel

To test your commit locally, please follow our guild on building from source.
Check out the developer guide for additional guidance on testing your change.

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines successfully started running 1 pipeline(s).

@ranadeepsingh Rana Singh (ranadeepsingh) changed the title bugfix: LightGBM Ensure non-duplicate column names fix: LightGBM Ensure non-duplicate column names Feb 27, 2026
@codecov-commenter

Codecov Comments Bot (codecov-commenter) commented Feb 27, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 87.03%. Comparing base (c570407) to head (d9a915e).

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #2508      +/-   ##
==========================================
+ Coverage   86.76%   87.03%   +0.27%     
==========================================
  Files         338      338              
  Lines       18785    18817      +32     
  Branches     1804     1803       -1     
==========================================
+ Hits        16299    16378      +79     
+ Misses       2486     2439      -47     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@ranadeepsingh

Copy link
Copy Markdown
Collaborator Author

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines successfully started running 1 pipeline(s).

@ranadeepsingh

Copy link
Copy Markdown
Collaborator Author

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines successfully started running 1 pipeline(s).

@ranadeepsingh

Copy link
Copy Markdown
Collaborator Author

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines successfully started running 1 pipeline(s).

@ranadeepsingh

Copy link
Copy Markdown
Collaborator Author

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines successfully started running 1 pipeline(s).

@ranadeepsingh

Copy link
Copy Markdown
Collaborator Author

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines successfully started running 1 pipeline(s).

Copilot AI lite review requested due to automatic review settings August 11, 2026 22:21
@ranadeepsingh

Copy link
Copy Markdown
Collaborator Author

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes LightGBM training failures caused by duplicate feature names (including collisions after LightGBM’s space-to-underscore normalization) by ensuring slot/feature names are unique and validated before the first native call that consumes them. It also propagates feature names onto the streaming reference Dataset and refactors reference-dataset creation for clearer lifecycle management.

Changes:

  • De-duplicate feature/slot names (from both slotNames and AttributeGroup metadata) using LightGBM-style normalization rules.
  • Validate slot names earlier in fit to fail fast with actionable errors before native feature-name calls.
  • Name the streaming reference Dataset consistently and add regression tests covering duplicate/collision scenarios.
Show a summary per file
File Description
lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/LightGBMBase.scala Ensure unique feature names (including normalized collisions) and validate names before native calls.
lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/dataset/ReferenceDatasetUtils.scala Refactor reference Dataset creation and (now) apply feature names to reference datasets.
lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/split1/VerifyLightGBMCommon.scala Add tests for duplicate feature names from metadata and explicit slotNames, including collision edge cases.

Review details

Tip

Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Suppressed comments (1)

lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/dataset/ReferenceDatasetUtils.scala:94

  • serializeAndCleanup frees the Dataset handle. If the caller also adds defensive cleanup (needed for failures before serialization), this risks double-free. Prefer making Dataset lifetime ownership explicit: serialize here, but free the Dataset in the caller's finally so all failure paths free exactly once.
    LightGBMUtils.validate(lightgbmlib.LGBM_DatasetSerializeReferenceToBinary(
      datasetHandle, bufferHandlePtr, lenPtr), "Serialize ref")
    val bufferLen: Int = lightgbmlib.intp_value(lenPtr)
    log.info(s"Created serialized reference dataset of length $bufferLen")
    LightGBMUtils.validate(lightgbmlib.LGBM_DatasetFree(datasetHandle), "Free Dataset")
    toByteArray(bufferHandlePtr, bufferLen)
  • Files reviewed: 3/3 changed files
  • Comments generated: 2
  • Review effort level: Lite

Copilot AI review requested due to automatic review settings August 11, 2026 23:05
@ranadeepsingh

Copy link
Copy Markdown
Collaborator Author

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

Suppressed comments (2)

lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/LightGBMBase.scala:587

  • getSlotNamesWithMetadata (and therefore ensureUniqueFeatureNames) is invoked multiple times during a single fit in streaming mode (e.g., once inside calculateRowStatistics and again when building the TrainingContext in executeTraining). Since ensureUniqueFeatureNames logs a warning when it renames duplicates, the same warning can be emitted more than once per batch, which is noisy and can look like multiple independent problems. Consider computing the (unique) feature name array once per batch and threading it through to all consumers.
    // Get feature names to set on the reference dataset (ensures unique names for Spark 3.5+)
    val featureNames = getSlotNamesWithMetadata(featuresSchema)

lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/LightGBMBase.scala:287

  • validateSlotNames currently validates the de-duplicated names returned by getSlotNamesWithMetadata. If the input contains invalid characters and also has duplicates, ensureUniqueFeatureNames can append a suffix (e.g., bad,name_1), and the thrown IllegalArgumentException will list names the user never supplied, making the error harder to act on. Validate the raw names first (either explicit slotNames or metadata-derived names) and only then de-duplicate for LightGBM.

This issue also appears on line 585 of the same file.

  private def validateSlotNames(featuresSchema: StructField): Unit = {
    val slotNamesOpt = getSlotNamesWithMetadata(featuresSchema)
    val pattern = new Regex("[\",:\\[\\]{}]")
    slotNamesOpt.foreach(slotNames => {
      val badSlotNames = slotNames.flatMap(slotName =>
        if (pattern.findFirstIn(slotName).isEmpty) None else Option(slotName))
      if (!badSlotNames.isEmpty) {
  • Files reviewed: 3/3 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Rebase onto current master and address three defects found while reviewing
the original change.

Duplicate detection was incomplete in two ways, and both still produced the
error this branch exists to fix:

- A generated name could collide with an original name appearing later, so
  ["Column_", "Column_", "Column__1"] still failed with
  "Feature (Column__1) appears more than one time". All original names are
  now reserved up front.
- LightGBM replaces spaces with underscores before comparing feature names,
  so "a b" and "a_b" are one feature natively and failed with
  "Feature (a_b) appears more than one time". Uniqueness is now decided on
  the normalized form while the original names are still emitted.

Naming the streaming reference Dataset introduces an LGBM_DatasetSetFeatureNames
call inside calculateRowStatistics, which runs earlier in trainOneDataBatch
than validateSlotNames did. Invalid names therefore surfaced as the opaque
native "Do not support special JSON characters in feature name" instead of the
actionable IllegalArgumentException, breaking the existing
"Verify LightGBM Regressor with bad column names fails early" test.
validateSlotNames now runs as soon as featuresSchema is resolved.

Also:

- Remove the bulk-mode retry fallback. Its guard required the message to
  contain "dataset create", but the duplicate-name error comes from
  LGBM_DatasetSetFeatureNames, so it could never match; and BulkPartitionTask
  sets the same names, so the retry would fail identically. It also mutated
  the estimator's dataTransferMode param mid-fit.
- Free the voidpp handle in createDatasetFromSamples, matching
  deserializeReferenceDataset. The previous code leaked it on every
  streaming fit().
- Skip feature names with a warning when their count does not match numCols,
  since LGBM_DatasetSetFeatureNames reads numCols entries.
- Revert stray setUseBarrierExecutionMode(true) in the ranker and regressor
  test data base classes; they are EstimatorFuzzing bases, so the flag
  applied to every ranker and regressor test.

Tests: VerifyLightGBMCommon 9/9, regressor/ranker stream 26/26, bulk and
network suites 52/52, scalastyle clean.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ame message

Addresses two review comments.

createReferenceDatasetFromSample allocated the native Dataset and only freed
it on the success path, inside serializeAndCleanup. Naming the Dataset added a
new throwing call between the allocation and that free, so a duplicate or
invalid feature name leaked the Dataset. The handle is now freed in a finally
that covers both naming and serialization, and serializeAndCleanup becomes
serializeReference since it no longer owns cleanup. The free is intentionally
not validated: throwing from the finally would mask the original failure.

The invalid slot name message listed backslash as a rejected character, but the
regex never matched it and LightGBM does not reject it either. Its CheckAllowedJSON
rejects exactly " , : [ ] { }. The message now matches both the regex and the
native behavior. The regex is deliberately unchanged; adding backslash to it
would reject names LightGBM accepts.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

Suppressed comments (1)

lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/LightGBMBase.scala:211

  • getSlotNamesWithMetadata assumes every Attribute entry is non-null. Spark attribute metadata can contain null slots (this file already handles case (null, _) in getCategoricalIndexes), and a null here would NPE during name extraction and break training.

Guard null attributes and fall back to the default index-based name when an entry is null.

          val colNames = attributes.indices.map(_.toString).toArray
          attributes.foreach(attr =>
            attr.index.foreach(index => colNames(index) = attr.name.getOrElse(index.toString)))
          // Ensure unique feature names to avoid LightGBM error:
  • Files reviewed: 3/3 changed files
  • Comments generated: 1
  • Review effort level: Lite

@ranadeepsingh

Copy link
Copy Markdown
Collaborator Author

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).

Review feedback: the length guard added for the reference dataset only covered
one of four call sites. LGBM_DatasetSetFeatureNames reads numCols entries from
the array, and slotNames is user-supplied and never length-checked upstream, so
the bulk and streaming per-partition paths could still pass a short array into
native code.

Verified rather than assumed. With the guard reverted, both new tests fail:

  Dataset set feature names call failed in LightGBM with error:
  basic_string: construction from null is not valid

The native read runs off the end of the Java String[], reads null, and
std::string construction from null throws -- crashing the executor task and
failing the whole fit. It reproduces in both streaming and bulk transfer modes.

Move the guard into LightGBMDataset.setFeatureNames so all naming paths are
protected, and add regression tests for both transfer modes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings August 12, 2026 02:35
@ranadeepsingh

Copy link
Copy Markdown
Collaborator Author

Good catch — you were right, and it was worse than a theoretical risk. Fixed by centralizing the guard in LightGBMDataset.setFeatureNames as you suggested.

I reverted the guard and ran the new tests to confirm the exposure is real rather than assume it:

\
java.lang.Exception: Dataset set feature names call failed in LightGBM with error:
basic_string: construction from null is not valid
\\

LGBM_DatasetSetFeatureNames reads numCols entries, runs off the end of the Java String[], gets null, and std::string construction from null throws — killing the executor task and failing the whole fit. It reproduces in both streaming and bulk transfer modes, exactly the paths you identified. slotNames is user-supplied and never length-checked upstream (validateSlotNames only screens for special characters), so any user passing a mismatched length hits this.

Changes:

  • Length guard moved into LightGBMDataset.setFeatureNames, covering BulkPartitionTask, StreamingPartitionTask, and ReferenceDatasetUtils.createReferenceDatasetFromSample.
  • ReferenceDatasetUtils.setFeatureNamesIfProvided keeps its own guard since it operates on a raw SWIGTYPE_p_void handle and cannot delegate.
  • Two regression tests, one per transfer mode.

Validation: VerifyLightGBMCommon 11/11 pass with the guard, 2 fail without it. lightgbm/scalastyle and lightgbm/Test/scalastyle clean.

/azp run

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

Suppressed comments (2)

lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/dataset/LightGBMDataset.scala:186

  • The code skips naming when featureNamesArray.length != numCols, but the preceding comment only explains the short-array risk. Since the condition also skips longer arrays, the comment should be updated to reflect the full mismatch behavior to avoid confusion.
        // LGBM_DatasetSetFeatureNames reads numCols entries from the array, so a shorter array
        // is an out-of-bounds native read. slotNames is user-supplied and unvalidated, so guard
        // every dataset-naming path here rather than at individual call sites. LightGBM falls
        // back to its own generated names when naming is skipped.

lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/dataset/ReferenceDatasetUtils.scala:80

  • The guard skips naming when names.length != numCols, but the comment only justifies the short-array case. This is misleading for future maintainers (the current condition also skips when the array is longer than numCols). Update the comment to describe the full mismatch behavior and rationale.
      if (names.length != numCols) {
        // LGBM_DatasetSetFeatureNames reads numCols entries from the array, so a shorter array
        // would be an out-of-bounds native read. Skip naming rather than risk it; LightGBM then
        // falls back to its own generated names, which is the behavior prior to this feature.
  • Files reviewed: 4/4 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

@ranadeepsingh

Copy link
Copy Markdown
Collaborator Author

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Error while using "LightGMB" on Fabric

4 participants